Popular Searches
Popular Course Categories
Popular Courses

Collecting Data Streams Using Kotlin Flow

Collecting Data Streams Using Kotlin Flow

Coroutines and Asynchronous Programming

Collecting Data Streams Using Kotlin Flow

Course: Android App Development

Topic: Collecting Data Streams Using Kotlin Flow

Course Link: Android App Development Course

Demo Link: Register for a Free Course Demo

Website: JustAcademy


1. Introduction to Kotlin Flow

Kotlin Flow is an asynchronous data-stream API provided by Kotlin Coroutines. It allows Android applications to produce, transform, and collect multiple values over time without blocking the main thread when used correctly. Unlike a regular function that generally returns a single result, a Flow can emit multiple values sequentially as new data becomes available.

Modern Android applications frequently work with continuously changing data, including database records, network responses, search suggestions, user input, download progress, sensor readings, and application state. Kotlin Flow provides a structured way to observe and process these updates.

For example, a shopping application may display a list of products from a database. When the database changes, the application can receive updated product data through a Flow and refresh the screen automatically.

Learning Objectives

  • Understand Kotlin Flow and asynchronous data streams.
  • Learn how to create and collect a Flow.
  • Understand producers, intermediate operators, and collectors.
  • Differentiate cold Flow, StateFlow, and SharedFlow.
  • Transform, filter, combine, and handle errors in data streams.
  • Collect Flow safely according to the Android lifecycle.
  • Apply Flow in repositories, ViewModels, and Android user interfaces.

2. What Is a Data Stream?

A data stream is a sequence of values delivered over time. Instead of waiting for a single final result, a consumer can receive values as they become available.

Consider a file download that reports progress at regular intervals. The stream may emit 10%, 25%, 50%, 75%, and 100%. Each emitted value allows the application to update its progress indicator.

Examples of Data Streams in Android

Use Case Example Stream Purpose
Database observation Updated list of products Refresh data when stored records change.
Search input Typed search queries Display relevant results dynamically.
Download progress Percentage completed Update progress indicators.
Application state Loading, success, error Render the appropriate screen.
Network monitoring Connectivity changes Respond to online and offline states.
Chat application Incoming messages Display messages as they arrive.

3. Understanding Kotlin Flow

A Flow represents a stream of values of the same type. It is built on Kotlin Coroutines and supports asynchronous processing, cancellation, and a collection of operators for manipulating data.

For example, Flow<Int> represents a flow that emits integer values, while Flow<String> represents a flow that emits strings.

Basic Flow Example

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

fun numberStream(): Flow<Int> = flow {
    emit(10)
    emit(20)
    emit(30)
}

In this example:

  • Flow<Int> declares a stream of integers.
  • flow { } creates a Flow using a builder.
  • emit() sends a value downstream to the collector.
  • The stream produces values when collection begins.

Creating a Flow does not necessarily execute its producer immediately. A regular Flow created with the flow builder is cold and normally starts executing when a terminal operator collects it.

4. The Three Main Parts of a Flow Pipeline

A Flow pipeline usually contains a producer, optional intermediate operators, and a collector.

4.1 Producer

The producer creates or retrieves data and emits it into the stream. It may generate values, read from a database, or adapt another data source.

val values = flow {
    emit("First")
    emit("Second")
    emit("Third")
}

4.2 Intermediate Operators

Intermediate operators receive upstream values, transform or filter them, and return another Flow. Common examples include map, filter, distinctUntilChanged, and onEach.

val evenNumbers = flow {
    emit(1)
    emit(2)
    emit(3)
    emit(4)
}.filter { number ->
    number % 2 == 0
}

The resulting stream contains only 2 and 4. Intermediate operators are generally lazy: they execute as values pass through the pipeline during collection.

4.3 Collector

The collector consumes emitted values. The collect() terminal operator starts collection and executes its action for each received value.

suspend fun printNumbers() {
    evenNumbers.collect { number ->
        println(number)
    }
}

Because collect() is a suspending terminal operation, it must run inside a coroutine or another suspending function. Collection may continue until the Flow completes or the collecting coroutine is cancelled.

5. Creating a Flow with the flow Builder

The flow builder is a common way to define a cold Flow. Values can be emitted sequentially using emit(). The producer block can call suspending functions, allowing asynchronous work such as delays or suspend-based data retrieval.

import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

fun countdown(): Flow<Int> = flow {
    for (number in 5 downTo 1) {
        emit(number)
        delay(1000)
    }
}

This Flow emits the values 5, 4, 3, 2, and 1 with a one-second delay between emissions. The producer starts when a collector begins collecting it.

Collecting the Countdown

import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.collect

fun main() = runBlocking {
    countdown().collect { value ->
        println("Countdown: $value")
    }
}

The output resembles the following, with a delay between each value:

Countdown: 5
Countdown: 4
Countdown: 3
Countdown: 2
Countdown: 1

Important: This main() example demonstrates Flow collection outside an Android screen. In a real application, use an appropriate lifecycle-aware coroutine scope instead of blocking the main thread.

6. Collecting Values Using collect()

The collect() operator is the primary way to consume all values emitted by a Flow. It accepts a lambda that runs whenever a new value arrives.

val names = flow {
    emit("Aarav")
    emit("Priya")
    emit("Rahul")
}

suspend fun displayNames() {
    names.collect { name ->
        println("Name: $name")
    }
}

Collection begins when displayNames() executes. Each name is delivered to the collector in emission order.

Key Characteristics of collect()

  • It is a terminal operator that starts collection.
  • It receives each value emitted by the upstream Flow.
  • It is a suspending function.
  • It can continue indefinitely if the upstream Flow never completes.
  • It can be cancelled through the coroutine that performs collection.

7. Cold Flow Versus Hot Flow

Understanding cold and hot streams is essential when designing Android applications.

Feature Cold Flow Hot Flow
Execution Usually begins when collected. Can produce or maintain data independently of a particular collector.
Collectors Each collector generally runs its own upstream collection. Multiple collectors can observe a shared stream.
Typical types Flow created with flow. StateFlow and SharedFlow.
Current value Not necessarily retained. StateFlow always stores a current state value.
Common uses Database queries, transformations, one-source pipelines. Shared application state and event broadcasts.

Cold Flow Example

val dataFlow = flow {
    println("Producer started")
    emit("Data received")
}

The message Producer started is printed when a collector starts collecting. If two collectors independently collect this cold Flow, the producer normally runs twice.

When to Use Cold Flow

  • When each collection should trigger a fresh data operation.
  • When creating a reusable data pipeline.
  • When observing data through a Flow returned by a repository or data source.

8. Transforming Data with map()

The map() operator transforms each emitted value into another value. It is useful when converting raw data into a display-friendly format or mapping repository data into domain models.

val prices = flow {
    emit(100)
    emit(200)
    emit(300)
}

val pricesWithTax = prices.map { price ->
    price * 1.18
}

Each price is multiplied by 1.18 to calculate a value that includes an illustrative 18% tax. In a production application, use an appropriate monetary representation and apply the actual tax rules.

Converting Names to Uppercase

val uppercaseNames = flow {
    emit("android")
    emit("kotlin")
    emit("flow")
}.map { name ->
    name.uppercase()
}

The collector receives ANDROID, KOTLIN, and FLOW.

9. Filtering Data with filter()

The filter() operator allows only values that satisfy a condition to continue downstream.

val numbers = flow {
    emit(5)
    emit(10)
    emit(15)
    emit(20)
    emit(25)
}

val filteredNumbers = numbers.filter { number ->
    number >= 15
}

The resulting stream emits 15, 20, and 25.

Practical Android Example

Suppose an application receives a list of products and should display only products that are in stock. A filtering operation can remove unavailable products before the data reaches the UI.

data class Product(
    val name: String,
    val inStock: Boolean
)

val availableProducts = productFlow.map { products ->
    products.filter { product ->
        product.inStock
    }
}

This example assumes that productFlow emits lists of Product objects.

10. Combining Multiple Flow Operators

Multiple intermediate operators can be chained together to create a readable data-processing pipeline. Each operator receives the result of the previous operator.

val processedNumbers = flow {
    emit(1)
    emit(2)
    emit(3)
    emit(4)
    emit(5)
    emit(6)
}.filter { number ->
    number % 2 == 0
}.map { number ->
    number * number
}.distinctUntilChanged()

The pipeline first keeps even numbers, then squares them. The resulting values are 4, 16, and 36.

Operator Summary

Operator Purpose Example
map Transforms each value. Convert a name to uppercase.
filter Keeps values satisfying a condition. Keep products in stock.
onEach Performs an action for each value while preserving it. Log received values.
distinctUntilChanged Suppresses consecutive equal values. Avoid repeated identical search queries.
take Limits the number of collected values. Receive the first five events.
catch Handles eligible upstream exceptions. Emit a fallback result after a failure.
onCompletion Observes completion or cancellation. Record when collection ends.
combine Combines the latest values from multiple Flows. Combine filters and search input.
flatMapLatest Switches to a new inner Flow and cancels the previous one. Search as the user types.

11. Understanding StateFlow

StateFlow is a hot Flow designed to represent a current state that changes over time. It always has an initial value, retains the latest state, and delivers the current value to new collectors.

In Android, StateFlow is commonly used in a ViewModel to expose screen state such as loading indicators, retrieved data, validation messages, and error information.

Creating StateFlow

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

class CounterViewModel {
    private val _count = MutableStateFlow(0)

    val count: StateFlow<Int> = _count.asStateFlow()

    fun increment() {
        _count.value += 1
    }
}

The mutable StateFlow is kept private so that other classes cannot directly change the state. The public property exposes a read-only StateFlow.

When increment() is called, the current value changes and active collectors can receive the new state. StateFlow suppresses consecutive equal values according to equality comparison.

When to Use StateFlow

  • Representing the current screen state.
  • Exposing data from a ViewModel.
  • Tracking loading, success, and failure states.
  • Maintaining the latest filter or selection.

12. Understanding SharedFlow

SharedFlow is a hot stream that broadcasts emitted values to its active collectors. Unlike StateFlow, a general SharedFlow does not inherently require a current state value.

It is useful when multiple parts of an application need to receive shared events, such as application-wide notifications or refresh signals. Replay and buffering settings determine how emissions are delivered to subscribers.

Creating SharedFlow

import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow

class EventRepository {
    private val _events = MutableSharedFlow<String>()

    val events: SharedFlow<String> = _events.asSharedFlow()

    suspend fun sendEvent(message: String) {
        _events.emit(message)
    }
}

With the default configuration, a new subscriber does not receive older emissions that occurred before it subscribed. The producer may suspend while emitting if required by the SharedFlow configuration and active subscribers.

StateFlow and SharedFlow Comparison

Feature StateFlow SharedFlow
Initial value Required. Not inherently required.
Current state Always stores one current value. Does not inherently represent a current state.
New collectors Receive the current state first. Receive replayed values if replay is configured.
Best suited for UI state and latest values. Broadcast events and shared emissions.
Completion Does not normally complete on its own. Does not normally complete on its own.

13. Collecting Flow in an Android ViewModel

A ViewModel provides a lifecycle-aware location for screen-related state and business logic. The viewModelScope coroutine scope is cancelled when the ViewModel is cleared, helping prevent unnecessary work from continuing beyond the ViewModel's lifetime.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch

sealed interface ProductsUiState {
    data object Loading : ProductsUiState
    data class Success(val products: List<String>) : ProductsUiState
    data class Error(val message: String) : ProductsUiState
}

class ProductsViewModel(
    private val repository: ProductsRepository
) : ViewModel() {

    private val _uiState =
        MutableStateFlow<ProductsUiState>(ProductsUiState.Loading)

    val uiState: StateFlow<ProductsUiState> =
        _uiState.asStateFlow()

    init {
        viewModelScope.launch {
            repository.observeProducts()
                .onStart {
                    _uiState.value = ProductsUiState.Loading
                }
                .catch { exception ->
                    _uiState.value = ProductsUiState.Error(
                        exception.message ?: "Unable to load products"
                    )
                }
                .collect { products ->
                    _uiState.value = ProductsUiState.Success(products)
                }
        }
    }
}

This example assumes that ProductsRepository.observeProducts() returns a Flow of product lists. The ViewModel exposes one state stream so the UI can render loading, success, or error states consistently.

Important: The catch operator handles eligible upstream failures, but it should not be treated as a universal handler for exceptions thrown by the downstream collector. Cancellation should also be allowed to propagate rather than being converted into an ordinary application error.

14. Collecting Flow Safely with the Android Lifecycle

Flow collection does not automatically stop just because an Activity or Fragment becomes invisible. Collecting UI data without lifecycle awareness can waste resources and may attempt to update a screen that is no longer active.

For Activity-based interfaces, use repeatOnLifecycle with an appropriate lifecycle state. The collection block starts when the lifecycle reaches the selected state and is cancelled when the lifecycle falls below it.

Activity Example

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch

class ProductsActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    when (state) {
                        is ProductsUiState.Loading -> {
                            showLoading()
                        }
                        is ProductsUiState.Success -> {
                            showProducts(state.products)
                        }
                        is ProductsUiState.Error -> {
                            showError(state.message)
                        }
                    }
                }
            }
        }
    }
}

The functions showLoading(), showProducts(), and showError() represent UI-rendering methods that must be implemented by the application. The viewModel property must also be initialized through the application's chosen ViewModel setup.

For a Fragment, collect using the view lifecycle, typically through viewLifecycleOwner.lifecycleScope and viewLifecycleOwner.repeatOnLifecycle, so collection follows the lifetime of the Fragment's view rather than only the Fragment instance.

15. Using Flow with Repository and Data Layers

Android applications often separate data access from UI logic. A repository can expose a Flow to the ViewModel, while a local database or remote data source supplies the values.

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

class ProductsRepository(
    private val remoteDataSource: ProductsRemoteDataSource
) {
    fun observeProducts(): Flow<List<String>> = flow {
        val products = remoteDataSource.fetchProducts()
        emit(products)
    }
}

class ProductsRemoteDataSource {
    suspend fun fetchProducts(): List<String> {
        // Replace with a real network request.
        return listOf("Phone", "Tablet", "Laptop")
    }
}

This simplified repository emits one list. A Flow does not automatically make every data source continuously observable: the number and timing of emissions depend on how the upstream stream is implemented.

For a database such as Room, a DAO query can return a Flow that emits updated query results when relevant database data changes. A network request, by contrast, often returns one response unless polling, callbacks, or a streaming protocol is used.

16. Using onEach() for Side Effects

The onEach() operator performs an action for every value without replacing that value. It is useful for logging, analytics, or triggering a side effect as values pass through a pipeline.

val loggedFlow = flow {
    emit("Android")
    emit("Kotlin")
    emit("Flow")
}.onEach { value ->
    println("Received: $value")
}

The action runs when the Flow is collected. Creating loggedFlow alone does not execute the producer or the side effect.

Keep side effects controlled and avoid placing expensive blocking work inside an operator that may execute in a context unsuitable for that work.

17. Handling Errors in Kotlin Flow

Failures can occur during data retrieval, transformation, or processing. Flow provides operators that help observe and handle upstream exceptions.

Using catch()

val safeFlow = flow {
    emit("First result")
    throw IllegalStateException("Data source failed")
}.catch { exception ->
    emit("Fallback: ${exception.message}")
}

The collector receives the first result followed by the fallback value. The catch operator handles eligible exceptions from upstream of its position. It does not automatically catch failures thrown by the downstream collector.

Using onCompletion()

val monitoredFlow = flow {
    emit(1)
    emit(2)
}.onCompletion { cause ->
    if (cause == null) {
        println("Flow completed successfully")
    } else {
        println("Flow ended with: ${cause.message}")
    }
}

onCompletion() observes how collection finishes, including normal completion, failure, and cancellation. Use it for appropriate cleanup or diagnostics rather than as a replacement for error handling.

Error-Handling Best Practices

  • Handle expected failures near the layer that understands them.
  • Expose meaningful error states to the UI.
  • Do not silently hide important exceptions.
  • Do not convert coroutine cancellation into a normal success or failure result.
  • Use retry logic only when repeating the operation is safe and useful.
  • Keep error messages suitable for users and avoid exposing sensitive technical details.

18. Changing Execution Context with flowOn()

The flowOn() operator changes the coroutine context used by the upstream portion of a Flow pipeline. It is useful when upstream work requires a particular dispatcher.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map

val transformedFlow = flow {
    emit("Android")
    emit("Kotlin")
}.map { value ->
    value.uppercase()
}.flowOn(Dispatchers.Default)

The producer and upstream transformations run in the context configured by flowOn(). Downstream collection remains in the collector's context unless another operation changes it.

Dispatcher Typical Use
Dispatchers.Main Updating Android UI and performing main-thread work.
Dispatchers.IO Blocking I/O operations such as legacy file or database access.
Dispatchers.Default CPU-intensive work such as large computations or transformations.

Many modern suspend APIs already manage their execution context appropriately. Do not add dispatcher switches automatically without understanding what the upstream operation requires.

19. Combining Multiple Flows with combine()

The combine() operator combines the latest available values from two or more Flows. It emits a new result when an input Flow produces a new value, once the inputs have supplied values needed for the combination.

import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf

val firstNameFlow = flowOf("Aarav")
val lastNameFlow = flowOf("Sharma")

val fullNameFlow = combine(
    firstNameFlow,
    lastNameFlow
) { firstName, lastName ->
    "$firstName $lastName"
}

The combined Flow emits Aarav Sharma.

Practical Use Cases

  • Combining a search query with selected filters.
  • Combining profile information with user preferences.
  • Combining location data with map display settings.
  • Creating a screen model from multiple repository streams.

20. Using flatMapLatest() for Search and Live Queries

The flatMapLatest() operator switches to a new inner Flow whenever the upstream emits a new value. It cancels collection of the previous inner Flow, making it useful for requests where only the latest input matters.

import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf

fun searchProducts(query: String): Flow<List<String>> = flow {
    delay(300)
    emit(
        listOf("Phone", "Tablet", "Laptop")
            .filter { it.contains(query, ignoreCase = true) }
    )
}

val queryFlow = flowOf("p", "ph", "pho")

val resultsFlow = queryFlow.flatMapLatest { query ->
    searchProducts(query)
}

In this simplified example, each query triggers a search Flow. When a newer query arrives, the previous inner Flow is cancelled. The operator is useful for reducing obsolete work, although actual network cancellation depends on whether the underlying API cooperates with coroutine cancellation.

Depending on the coroutine library version, some operators may require the appropriate Flow imports and experimental opt-ins. Verify the project's Kotlin and Coroutines versions when implementing this pattern.

21. Avoiding Duplicate Values with distinctUntilChanged()

The distinctUntilChanged() operator suppresses consecutive values that compare equal. It is particularly useful for text input, UI state, and filter changes.

val searchQueries = flow {
    emit("android")
    emit("android")
    emit("kotlin")
    emit("kotlin")
    emit("flow")
}.distinctUntilChanged()

The resulting stream contains android, kotlin, and flow. Non-consecutive duplicates are not necessarily removed; this operator focuses on consecutive equal values.

22. Understanding Debounce for Search Input

The debounce() operator waits until no new value has arrived for a specified time interval before forwarding the latest value. This is helpful for search screens because users often type several characters in quick succession.

val debouncedQueries = queryFlow
    .debounce(300)
    .distinctUntilChanged()

This example assumes queryFlow emits search queries and the relevant coroutine Flow library version provides the required operator. The debounce interval is 300 milliseconds.

Debouncing can reduce unnecessary requests, but it does not replace server-side rate limiting, caching, cancellation, or proper error handling.

23. Buffering and Flow Processing

By default, Flow processing generally follows sequential execution: upstream emission and downstream processing proceed in a coordinated way. The buffer() operator can allow upstream production and downstream collection to proceed more independently using a channel buffer.

val bufferedFlow = flow {
    emit("A")
    emit("B")
    emit("C")
}.buffer()

Buffering may improve throughput when production and consumption can overlap. However, it can also increase memory use or allow a backlog to build up. Choose buffer capacity and overflow behavior according to the requirements of the stream.

24. Common Mistakes When Collecting Flow

Mistake Why It Is a Problem Recommended Solution
Assuming Flow executes when declared A cold Flow usually starts only when collected. Use a terminal operator in the correct coroutine.
Collecting UI state without lifecycle awareness Collection can continue while the screen is stopped. Use repeatOnLifecycle for Activity or Fragment UI collection.
Using collect for a one-time value without considering alternatives A stream may emit multiple values or never complete. Choose an appropriate terminal operator, such as first(), when only one value is required.
Blocking the main thread Long-running blocking work can freeze the UI. Use suspend APIs and suitable dispatchers.
Exposing MutableStateFlow publicly Any consumer could modify internal state. Expose a read-only StateFlow.
Ignoring cancellation Obsolete work may continue and waste resources. Use structured concurrency and cancellation-cooperative APIs.
Using catch as a universal exception handler Downstream collector failures are not handled as upstream failures. Place error handling at the correct pipeline level.
Collecting the same cold Flow repeatedly by accident Each collector may repeat the upstream operation. Share upstream work when multiple consumers need the same stream.

25. Best Practices for Kotlin Flow in Android

  • Use Flow for sequential asynchronous streams of data.
  • Use StateFlow for the latest screen state and SharedFlow for shared event streams where appropriate.
  • Keep data access inside repository or data-source layers.
  • Expose read-only Flow types to consumers whenever possible.
  • Collect UI data with lifecycle-aware APIs.
  • Use viewModelScope for ViewModel-owned work.
  • Use operators such as map, filter, and combine to keep transformations clear.
  • Use flowOn when upstream execution requires a different coroutine context.
  • Handle expected errors without swallowing cancellation.
  • Test Flow behavior, including emissions, failures, and cancellation.
  • Be deliberate about sharing, replay, buffering, and retry policies.

26. Practical Project: Live Product Search

Project Goal: Build a simple product-search data pipeline that accepts queries, filters products, and exposes the resulting state through StateFlow.

Step 1: Define the Product Model

data class Product(
    val id: Int,
    val name: String,
    val price: Double
)

Step 2: Create a Repository

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map

class ProductRepository {
    private val products = listOf(
        Product(1, "Android Phone", 25000.0),
        Product(2, "Laptop", 65000.0),
        Product(3, "Tablet", 30000.0),
        Product(4, "Bluetooth Headphones", 2500.0)
    )

    fun searchProducts(query: String): Flow<List<Product>> {
        return flowOf(
            products.filter { product ->
                product.name.contains(
                    query,
                    ignoreCase = true
                )
            }
        )
    }
}

This demonstration repository emits one filtered list for each call. In a real search screen, the query stream can be connected to this method using operators such as debounce and flatMapLatest.

Step 3: Define the Search ViewModel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn

class ProductSearchViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    private val _query = MutableStateFlow("")

    val query: StateFlow<String> = _query.asStateFlow()

    val results = _query
        .debounce(300)
        .distinctUntilChanged()
        .flatMapLatest { searchText ->
            repository.searchProducts(searchText)
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    fun updateQuery(newQuery: String) {
        _query.value = newQuery
    }
}

This ViewModel assumes the appropriate coroutine Flow operators are available in the project's dependencies. The query changes are debounced, duplicate consecutive queries are suppressed, and obsolete inner search streams are cancelled. The results are converted to StateFlow so the UI can observe the latest list.

Step 4: Connect the User Interface

In the UI, update the query state when the user types and collect results using lifecycle-aware collection. Render each product in a RecyclerView, LazyColumn, or another suitable list component.

Expected Behavior

  • Entering "phone" displays Android Phone.
  • Entering "tablet" displays Tablet.
  • Clearing the query displays all products in this sample implementation.
  • Rapid typing is debounced before the search pipeline processes the latest query.
  • The UI receives the most recent search results through StateFlow.

27. Exercises for Practice

  1. Create a Flow that emits five city names and collect them using collect().
  2. Use map() to convert a stream of integers into their squares.
  3. Use filter() to emit only prices greater than 1,000.
  4. Create a MutableStateFlow that stores a username and updates when the username changes.
  5. Create a MutableSharedFlow that broadcasts refresh events to two collectors.
  6. Combine two Flows to create a complete user display name.
  7. Use catch() to provide a fallback value when an upstream operation fails.
  8. Use debounce() and flatMapLatest() to build a search pipeline.
  9. Collect a ViewModel StateFlow using repeatOnLifecycle.
  10. Build a product list that updates when its underlying database stream emits new data.

28. Interview Questions and Answers

Q1. What is Kotlin Flow?

Kotlin Flow is an asynchronous stream API that can emit multiple values sequentially and provides operators for transforming, filtering, and collecting those values.

Q2. What is the difference between Flow and a suspend function?

A suspend function typically returns one result per invocation, while a Flow can emit multiple values over time.

Q3. What is a cold Flow?

A cold Flow generally starts executing its producer when a terminal operator begins collection. Each independent collector normally triggers a separate upstream execution.

Q4. What does collect() do?

collect() is a terminal operator that starts collection and processes each emitted value. It is a suspending function.

Q5. What is the difference between StateFlow and SharedFlow?

StateFlow represents the latest state and requires an initial value. SharedFlow broadcasts emissions to subscribers and supports configurable replay and buffering.

Q6. What is the purpose of map()?

map() transforms every emitted value into another value and returns a new Flow.

Q7. What is the purpose of filter()?

filter() allows only values that satisfy a condition to pass downstream.

Q8. Why is repeatOnLifecycle important?

It starts collection when the UI reaches the selected lifecycle state and cancels the collection when the lifecycle falls below that state.

Q9. What does flowOn() do?

flowOn() changes the coroutine context used by the upstream portion of the Flow pipeline.

Q10. What is flatMapLatest() used for?

It switches to the newest inner Flow and cancels the previous inner collection, making it useful for search input and other latest-request-wins operations.

Q11. What is the purpose of catch()?

catch() handles eligible exceptions from upstream operations. It does not catch exceptions thrown by the downstream collector.

Q12. How can a Flow be shared between multiple consumers?

Cold Flow work can be shared using operators such as shareIn() or converted into a state holder using stateIn(), with an appropriate scope and sharing policy.

29. Quick Revision Checklist

  • Flow represents an asynchronous stream of values.
  • The producer emits values, operators transform them, and the collector consumes them.
  • A cold Flow normally starts when collected.
  • collect() starts collection and processes each emitted value.
  • map() transforms values, while filter() selects values.
  • StateFlow retains the latest state and requires an initial value.
  • SharedFlow broadcasts values and supports replay configuration.
  • combine() works with the latest values from multiple streams.
  • flatMapLatest() switches to the latest inner stream.
  • catch() handles eligible upstream failures.
  • flowOn() changes the upstream coroutine context.
  • Android UI collection should respect the screen lifecycle.

30. Conclusion

Kotlin Flow is an important tool for building responsive Android applications that work with changing data. It supports asynchronous streams, transformations, filtering, error handling, and the combination of multiple data sources. When used with Coroutines, ViewModels, repositories, and lifecycle-aware collection, Flow helps separate data processing from UI rendering and makes application behavior easier to maintain and test.

To deepen your Android development skills, explore the course and demo resources below.

31. Official Learning Resources

Previous Next
whatsapp